diff --git a/packages/ludic.jobs/README.md b/packages/ludic.jobs/README.md new file mode 100644 index 00000000..7561670a --- /dev/null +++ b/packages/ludic.jobs/README.md @@ -0,0 +1,74 @@ +# ludic.jobs + +Work a player takes on for pay: written jobs offered by standing and story, boards of posts rolled +by the day, progress counted from what happens and read back from what the state proves, handing +in, and the reward paid through the game. Uses [`ludic.base`](../ludic.base/README.md) and nothing +else. + +```ludic +import "ludic.jobs" +``` + +A job is `(kind, param, need)` plus a reward (`money`, `rep`), a scope (`JOBS_SELF` yours, +`JOBS_PARTY` the party's) and a gate (`minrep` in its scope, `minstage` of the game's story). What a +kind measures is the game's: it counts a deed with `jobs_count(kind, param, n)`, and answers +`JobsWorld.evidence(kind, param)` with what the state already proves (-1: nothing). The game fills +the registries - `def Jobs from "jobs.lres"`, `def JobBoards from "boards.lres"` - and words every +line itself. + +## The rules it keeps + +1. **Events count, state proves, progress only goes up.** A job under way takes `jobs_count` deeds + and is reconciled every tick against the evidence, taking whichever is further; work done + before a written job was taken counts, and a post counts only what came after it went up. +2. **A job with a need is ready when it is met**; one with `need: 0` (and no kind) is the game's + own play, which says so with `jobs_ready` or pays at once with `jobs_complete`. +3. **A board rolls from its own dice.** `jobs_morning()` replaces every empty, stale (`keep` days) + or earlier-finished post, seeding the jobs' `Rng` from the day, the board, the slot and how many + posts came before, and asking `JobsWorld.roll(board, slot)`, which draws with `jobs_roll` and + posts with `jobs_post`. The same day rolls the same board on every machine. +4. **Scopes have keepers.** Where `JobsWorld.owns(scope)` is false (a guest's party jobs), + `jobs_count`, the recheck and the morning leave them alone; the keeper applies another member's + deed with `jobs_count_in(scope, ...)` and a guest takes the keeper's word with `jobs_set` / + `jobs_post_set`. +5. **Paid once, through a port.** `jobs_hand_in` / `jobs_post_hand_in` (or the moment an `auto` + board's post is met) calls `JobsPay.reward(scope, money, rep)`. + +## The ports + +```ludic +export port JobsWorld { + rep: fn(int) -> int # standing in a scope (unbound 0) + stage: fn() -> int # the story's stage (0) + open: fn(int) -> bool # a job the game allows today (all) + evidence: fn(int, int) -> int # (kind, param) as the state proves it (-1) + day: fn() -> int # (1) + owns: fn(int) -> bool # this machine keeps the scope (all) + roll: fn(int, int) -> void # (board, slot): post with jobs_post (nothing) +} +export port JobsPay { reward: fn(int, int, int) -> void } # (scope, money, rep), required +``` + +## API + +| | | +| --- | --- | +| `JobDef { key, group, vendor, kind, param, need, money, rep, minrep, minstage, scope, title, text, how, icon }`, `open registry Jobs ... as JOB` | a written job (`group` is the game's list, `JOBS_GROUPS` of them) | +| `JobBoard { key, slots, keep, auto, refill, scope }`, `open registry JobBoards ... as BOARD` | a board of posts | +| `JOBS_OFFERED`, `JOBS_ACTIVE`, `JOBS_READY`, `JOBS_DONE`; `JOBS_SELF`, `JOBS_PARTY` | states and scopes | +| `jobs_state(i)`, `jobs_have(i)`, `jobs_offered(i)`, `jobs_under_way(i)`, `jobs_ready_now(i)` | a job | +| `jobs_take(i)`, `jobs_ready(i)`, `jobs_hand_in(i)`, `jobs_complete(i)`, `jobs_set(i, state, have)` | its verbs | +| `jobs_set_have(i, v)`, `jobs_add_have(i, n)`, `jobs_mark(i, bit)` | the game's own play | +| `jobs_count(kind, param, n)`, `jobs_count_in(scope, kind, param, n)`, `jobs_recheck()` | progress | +| `jobs_at(group, vendor)`, `jobs_taken(group)`, `jobs_shown(group, most)`, `jobs_first_open(group)`, `jobs_done(group)`, `jobs_set_done(group, n)` | lists and counts | +| `jobs_morning()`, `jobs_roll(lo, hi)`, `jobs_post(b, s, kind, param, need, money, rep)`, `jobs_config_seed(seed)` | posting | +| `jobs_slots(b)`, `jobs_posted(b)`, `jobs_post_kind / _param / _need / _have / _money / _rep / _state / _day(b, s)`, `jobs_posts_done(b)`, `jobs_total(b)` | the boards | +| `jobs_post_hand_in(b, s)`, `jobs_post_set(b, s, ...)`, `jobs_set_total(b, n)` | a post's verbs | +| `jobs_facts() -> Queue` | `{ what: JOBS_F_OFFERED / _PROGRESSED / _COMPLETED / _HANDED_IN, job, board, slot, kind, param, have, need, money, rep, scope }` | +| `jobs_system()`, `jobs_party_system()` | `"jobs"` (yours, the tick) and `"jobs.party"` (the party's), each saving its scope's jobs and boards by key | + +## Tests + +```bash +ludic test packages/ludic.jobs +``` diff --git a/packages/ludic.jobs/boards.ludic b/packages/ludic.jobs/boards.ludic new file mode 100644 index 00000000..310f0400 --- /dev/null +++ b/packages/ludic.jobs/boards.ludic @@ -0,0 +1,89 @@ +# boards.ludic - posting: the morning takes down what is stale and asks the game for a new one, on +# the jobs' own dice seeded by the day, so a board never moves anyone else's rolls +export function jobs_board_ok(b: int) -> bool { return b >= 0 and b < BOARD_COUNT } + +function jobs__at(b: int, s: int) -> int { + jobs__ensure() + return jobs__off[b] + s +} + +# a whole number in [lo, hi] from the jobs' dice: what JobsWorld.roll draws with +export function jobs_roll(lo: int, hi: int) -> int { + jobs__ensure() + return rng_between(jobs__dice, lo, hi) +} + +# inside JobsWorld.roll: what goes up in the slot being filled +export function jobs_post(b: int, s: int, kind: int, param: int, need: int, money: int, rep: int) -> void { + let k = jobs__at(b, s) + jobs__pk[k] = kind + jobs__pp[k] = param + jobs__pn[k] = need + jobs__pm[k] = money + jobs__pr[k] = rep + jobs__ph[k] = 0 + jobs__ps[k] = JOBS_ACTIVE + jobs__pd[k] = JobsWorld.day() + let e = JobsWorld.evidence(kind, param) + jobs__pb[k] = 0 + if e > 0 { jobs__pb[k] = e } +} + +function jobs__clear(k: int) -> void { + jobs__pk[k] = -1 + jobs__pp[k] = -1 + jobs__pn[k] = 0 + jobs__ph[k] = 0 + jobs__ps[k] = JOBS_OFFERED +} + +# take a slot down and ask the game for what replaces it +function jobs__repost(b: int, s: int) -> void { + let k = jobs__at(b, s) + let day = JobsWorld.day() + rng_seed(jobs__dice, jobs__seed + day * 7 + b * 7919 + s * 104729 + jobs__serial[b] * 31) + jobs__serial[b] += 1 + jobs__clear(k) + JobsWorld.roll(b, s) + if jobs__pk[k] >= 0 { jobs__fact(JOBS_F_OFFERED, -1, b, s, 0, jobs__pn[k]) } +} + +# a new day, on the boards this machine keeps: an empty or stale post is replaced, and one +# finished on an earlier day +export function jobs_morning() -> void { + jobs__ensure() + let day = JobsWorld.day() + for b in 0 .. BOARD_COUNT { + let bd = JobBoards[b] + if not JobsWorld.owns(bd.scope) { continue } + for s in 0 .. bd.slots { + let k = jobs__off[b] + s + let finished = jobs__ps[k] == JOBS_DONE and day > jobs__pd[k] + if jobs__pk[k] < 0 or finished or day - jobs__pd[k] >= bd.keep { jobs__repost(b, s) } + } + } +} + +function jobs__post_moved(b: int, s: int, have: int) -> void { + let k = jobs__off[b] + s + let was = jobs__ph[k] + jobs__ph[k] = have + if jobs__ph[k] > jobs__pn[k] { jobs__ph[k] = jobs__pn[k] } + if jobs__ph[k] > was { jobs__fact(JOBS_F_PROGRESSED, -1, b, s, jobs__ph[k], jobs__pn[k]) } + if jobs__ps[k] != JOBS_ACTIVE or jobs__ph[k] < jobs__pn[k] { return } + jobs__ps[k] = JOBS_READY + jobs__fact(JOBS_F_COMPLETED, -1, b, s, jobs__ph[k], jobs__pn[k]) + if JobBoards[b].auto { jobs_post_hand_in(b, s) } +} + +export function jobs_post_hand_in(b: int, s: int) -> bool { + if not jobs_board_ok(b) or s < 0 or s >= JobBoards[b].slots { return false } + let k = jobs__at(b, s) + if jobs__ps[k] != JOBS_READY { return false } + jobs__ps[k] = JOBS_DONE + jobs__total[b] += 1 + JobsPay.reward(JobBoards[b].scope, jobs__pm[k], jobs__pr[k]) + jobs__fact(JOBS_F_HANDED_IN, -1, b, s, jobs__ph[k], jobs__pn[k]) + if JobBoards[b].refill { jobs__repost(b, s) } + return true +} diff --git a/packages/ludic.jobs/count.ludic b/packages/ludic.jobs/count.ludic new file mode 100644 index 00000000..743c3486 --- /dev/null +++ b/packages/ludic.jobs/count.ludic @@ -0,0 +1,74 @@ +# count.ludic - progress: events the game counts (jobs_count), and state read back (the recheck), +# taking whichever is further - progress only ever goes up +function jobs__matches(want_kind: int, want_param: int, kind: int, param: int) -> bool { + if want_kind < 0 or want_kind != kind { return false } + return want_param < 0 or want_param == param +} + +# `n` of (kind, param) happened here: every job and post of a scope this machine keeps +export function jobs_count(kind: int, param: int, n: int) -> void { jobs__count(-1, kind, param, n) } + +# the same for one scope alone, kept or not: another member's deed arriving at the host +export function jobs_count_in(scope: int, kind: int, param: int, n: int) -> void { jobs__count(scope, kind, param, n) } + +function jobs__takes(only: int, scope: int) -> bool { + if only >= 0 { return scope == only } + return JobsWorld.owns(scope) +} + +function jobs__count(only: int, kind: int, param: int, n: int) -> void { + jobs__ensure() + for i in 0 .. JOB_COUNT { + let d = Jobs[i] + if jobs__st[i] != JOBS_ACTIVE or not jobs__takes(only, d.scope) { continue } + if not jobs__matches(d.kind, d.param, kind, param) { continue } + let was = jobs__hv[i] + jobs__hv[i] = was + n + jobs__moved(i, was) + } + for b in 0 .. BOARD_COUNT { + if not jobs__takes(only, JobBoards[b].scope) { continue } + for s in 0 .. JobBoards[b].slots { + let k = jobs__off[b] + s + if jobs__ps[k] != JOBS_ACTIVE or not jobs__matches(jobs__pk[k], jobs__pp[k], kind, param) { continue } + jobs__post_moved(b, s, jobs__ph[k] + n) + } + } +} + +function jobs__recheck_one(i: int) -> void { + let d = Jobs[i] + if jobs__st[i] != JOBS_ACTIVE or d.kind < 0 or not JobsWorld.owns(d.scope) { return } + let e = JobsWorld.evidence(d.kind, d.param) + if e <= jobs__hv[i] { return } + let was = jobs__hv[i] + jobs__hv[i] = e + jobs__moved(i, was) +} + +# every job under way against what the state already proves: work done before it was taken counts +# for a written job; a post counts only what came after it went up +export function jobs_recheck() -> void { + jobs__ensure() + for i in 0 .. JOB_COUNT { jobs__recheck_one(i) } + for b in 0 .. BOARD_COUNT { + if not JobsWorld.owns(JobBoards[b].scope) { continue } + for s in 0 .. JobBoards[b].slots { + let k = jobs__off[b] + s + if jobs__ps[k] != JOBS_ACTIVE or jobs__pk[k] < 0 { continue } + let e = JobsWorld.evidence(jobs__pk[k], jobs__pp[k]) + if e >= 0 and e - jobs__pb[k] > jobs__ph[k] { jobs__post_moved(b, s, e - jobs__pb[k]) } + } + } +} + +# offers newly open are said once; the first look after a reset says none of them +function jobs__offers() -> void { + jobs__ensure() + for i in 0 .. JOB_COUNT { + let now = jobs_offered(i) + if now and not jobs__seen[i] and jobs__primed { jobs__fact(JOBS_F_OFFERED, i, -1, -1, 0, Jobs[i].need) } + jobs__seen[i] = now + } + jobs__primed = true +} diff --git a/packages/ludic.jobs/defs.ludic b/packages/ludic.jobs/defs.ludic new file mode 100644 index 00000000..4da15821 --- /dev/null +++ b/packages/ludic.jobs/defs.ludic @@ -0,0 +1,67 @@ +# defs.ludic - a job and a board as the game writes them, the states, the scopes and the facts +export const JOBS_SELF: int = 0 # a personal job: yours, saved with your body +export const JOBS_PARTY: int = 1 # the party's: the world's, saved with the trip + +export const JOBS_OFFERED: int = 0 +export const JOBS_ACTIVE: int = 1 +export const JOBS_DONE: int = 2 # handed in and paid +export const JOBS_READY: int = 3 # everything in hand; waits to be handed in + +export const JOBS_GROUPS: int = 8 # the game's lists (a board of notices, a vendor's longer work) + +# `kind` is the game's (-1: nothing counts it; the game's own play says when it is ready), `param` +# narrows it (-1 any), `need` 0 is never ready by counting. Money and rep are paid through JobsPay. +export property JobDef { + key: string = "" + group: int = 0 + vendor: int = 0 + kind: int = -1 + param: int = -1 + need: int = 1 + money: int = 0 + rep: int = 0 + minrep: int = 0 # standing in its scope before it is offered + minstage: int = 0 # how far the game's story must be + scope: int = JOBS_SELF + title: string = "" + text: string = "" + how: string = "" + icon: string = "" +} + +# the game's jobs; saved by key, so order is free +export open registry Jobs of JobDef as JOB + +# a board of posts rolled by the game (JobsWorld.roll): `keep` days a post stays up, `auto` pays +# the moment it is met, `refill` replaces a handed-in post at once +export property JobBoard { + key: string = "" + slots: int = 3 + keep: int = 1 + auto: bool = false + refill: bool = true + scope: int = JOBS_SELF +} + +export open registry JobBoards of JobBoard as BOARD + +export const JOBS_F_OFFERED: int = 0 +export const JOBS_F_PROGRESSED: int = 1 +export const JOBS_F_COMPLETED: int = 2 +export const JOBS_F_HANDED_IN: int = 3 + +# `job` a written job, or -1 and `board` / `slot` a post; what it counted rides along, since a +# post handed in is replaced before anyone reads the fact +export property JobsFact { + what: int = 0 + job: int = -1 + board: int = -1 + slot: int = -1 + kind: int = -1 + param: int = -1 + have: int = 0 + need: int = 0 + money: int = 0 + rep: int = 0 + scope: int = JOBS_SELF +} diff --git a/packages/ludic.jobs/index.ludic b/packages/ludic.jobs/index.ludic new file mode 100644 index 00000000..b29ea912 --- /dev/null +++ b/packages/ludic.jobs/index.ludic @@ -0,0 +1,15 @@ +# ludic.jobs - a job is (kind, param, need, reward, scope, gate); what a kind measures is the +# game's, counted by jobs_count and read back through JobsWorld.evidence +module ludic_jobs uses ludic_base +numbers float +import "ludic.base" +import "defs.ludic" +import "ports.ludic" +import "state.ludic" +import "jobs.ludic" +import "count.ludic" +import "lists.ludic" +import "boards.ludic" +import "posts.ludic" +import "save.ludic" +import "system.ludic" diff --git a/packages/ludic.jobs/jobs.ludic b/packages/ludic.jobs/jobs.ludic new file mode 100644 index 00000000..b0e389ba --- /dev/null +++ b/packages/ludic.jobs/jobs.ludic @@ -0,0 +1,96 @@ +# jobs.ludic - the written jobs: offered by standing and story, taken, readied, handed in and paid +export function jobs_ok(i: int) -> bool { return i >= 0 and i < JOB_COUNT } + +export function jobs_state(i: int) -> int { + jobs__ensure() + return jobs__st[i] +} + +export function jobs_have(i: int) -> int { + jobs__ensure() + return jobs__hv[i] +} + +export function jobs_ready_now(i: int) -> bool { return jobs_state(i) == JOBS_READY } + +# taken and not yet paid +export function jobs_under_way(i: int) -> bool { + let s = jobs_state(i) + return s == JOBS_ACTIVE or s == JOBS_READY +} + +# on offer: not taken, the scope's standing and the story far enough, and the game allowing it +export function jobs_offered(i: int) -> bool { + let d = Jobs[i] + if jobs_state(i) != JOBS_OFFERED { return false } + return JobsWorld.rep(d.scope) >= d.minrep and JobsWorld.stage() >= d.minstage and JobsWorld.open(i) +} + +export function jobs_take(i: int) -> bool { + if not jobs_ok(i) or jobs_state(i) != JOBS_OFFERED { return false } + jobs__st[i] = JOBS_ACTIVE + jobs__hv[i] = 0 + jobs__recheck_one(i) + return true +} + +# a count has moved: a job with a need is ready when it is met +function jobs__moved(i: int, was: int) -> void { + let d = Jobs[i] + if d.need > 0 and jobs__hv[i] > d.need { jobs__hv[i] = d.need } + if jobs__hv[i] > was { jobs__fact(JOBS_F_PROGRESSED, i, -1, -1, jobs__hv[i], d.need) } + if jobs__st[i] == JOBS_ACTIVE and d.need > 0 and jobs__hv[i] >= d.need { jobs_ready(i) } +} + +# the game's own play: a step counter, a mask of targets met +export function jobs_set_have(i: int, v: int) -> void { + let was = jobs_have(i) + jobs__hv[i] = v + jobs__moved(i, was) +} + +export function jobs_add_have(i: int, n: int) -> void { jobs_set_have(i, jobs_have(i) + n) } + +export function jobs_mark(i: int, bit: int) -> void { jobs_set_have(i, jobs_have(i) | (1 << bit)) } + +# everything in hand +export function jobs_ready(i: int) -> void { + if jobs_state(i) != JOBS_ACTIVE { return } + jobs__st[i] = JOBS_READY + jobs__fact(JOBS_F_COMPLETED, i, -1, -1, jobs__hv[i], Jobs[i].need) +} + +export function jobs_hand_in(i: int) -> bool { + if not jobs_ok(i) or jobs_state(i) != JOBS_READY { return false } + let d = Jobs[i] + jobs__st[i] = JOBS_DONE + if d.group >= 0 and d.group < JOBS_GROUPS { jobs__done_n[d.group] += 1 } + JobsPay.reward(d.scope, d.money, d.rep) + jobs__fact(JOBS_F_HANDED_IN, i, -1, -1, jobs__hv[i], d.need) + return true +} + +# finished and paid in one go, whatever the count says +export function jobs_complete(i: int) -> bool { + if not jobs_ok(i) or not jobs_under_way(i) { return false } + jobs_ready(i) + return jobs_hand_in(i) +} + +# the state outright: the host's word for a party job, a test +export function jobs_set(i: int, state: int, have: int) -> void { + jobs__ensure() + jobs__st[i] = state + jobs__hv[i] = have +} + +export function jobs_done(group: int) -> int { + jobs__ensure() + if group < 0 or group >= JOBS_GROUPS { return 0 } + return jobs__done_n[group] +} + +export function jobs_set_done(group: int, n: int) -> void { + jobs__ensure() + if group >= 0 and group < JOBS_GROUPS { jobs__done_n[group] = n } +} diff --git a/packages/ludic.jobs/lists.ludic b/packages/ludic.jobs/lists.ludic new file mode 100644 index 00000000..cf24faf5 --- /dev/null +++ b/packages/ludic.jobs/lists.ludic @@ -0,0 +1,36 @@ +# lists.ludic - the written jobs as a screen lists them: a vendor's, the taken, the ones to show +# a vendor's offers and the ones it can take back, in a group (-1 every vendor) +export function jobs_at(group: int, vendor: int) -> []int { + let out = new []int + for i in 0 .. JOB_COUNT { + let d = Jobs[i] + if d.group != group or (vendor >= 0 and d.vendor != vendor) { continue } + let s = jobs_state(i) + if s == JOBS_DONE or (s == JOBS_OFFERED and not jobs_offered(i)) { continue } + push(out, i) + } + return out +} + +# every job of a group taken at some point, done ones too +export function jobs_taken(group: int) -> []int { + let out = new []int + for i in 0 .. JOB_COUNT { if Jobs[i].group == group and jobs_state(i) != JOBS_OFFERED { push(out, i) } } + return out +} + +# the first `most` of a group under way +export function jobs_shown(group: int, most: int) -> []int { + let out = new []int + for i in 0 .. JOB_COUNT { + if len(out) >= most { break } + if Jobs[i].group == group and jobs_under_way(i) { push(out, i) } + } + return out +} + +# the first under way and not yet met, or -1 +export function jobs_first_open(group: int) -> int { + for i in 0 .. JOB_COUNT { if Jobs[i].group == group and jobs_state(i) == JOBS_ACTIVE { return i } } + return -1 +} diff --git a/packages/ludic.jobs/package.ludic b/packages/ludic.jobs/package.ludic new file mode 100644 index 00000000..cef4925f --- /dev/null +++ b/packages/ludic.jobs/package.ludic @@ -0,0 +1,6 @@ +# ludic.jobs - work a player takes on for pay: written jobs gated by standing, boards of posts rolled +# by the day from their own dice, progress counted from events and read back from state, handing +# in, and rewards paid through a port. Uses ludic.base and nothing else. See README.md. +package "ludic.jobs" +version "0.1.0" +kind source diff --git a/packages/ludic.jobs/ports.ludic b/packages/ludic.jobs/ports.ludic new file mode 100644 index 00000000..8d4dc5ac --- /dev/null +++ b/packages/ludic.jobs/ports.ludic @@ -0,0 +1,23 @@ +# ports.ludic - what jobs asks the game, and how it pays. Unbound, JobsWorld is a standing of 0, a +# story at its start, every job open, no evidence, day one, everything this machine's, no posts. +export port JobsWorld { + rep: fn(int) -> int = fn jobs__zero_of # standing in a scope + stage: fn() -> int = fn jobs__zero + open: fn(int) -> bool = fn jobs__yes_of # a job the game allows today + evidence: fn(int, int) -> int = fn jobs__none # (kind, param) as the state proves it, -1 unknown + day: fn() -> int = fn jobs__day_one + owns: fn(int) -> bool = fn jobs__yes_of # this machine keeps a scope's jobs + roll: fn(int, int) -> void = fn jobs__no_roll # (board, slot): the game posts with jobs_post +} + +function jobs__zero_of(n: int) -> int { return 0 } +function jobs__zero() -> int { return 0 } +function jobs__yes_of(n: int) -> bool { return true } +function jobs__none(kind: int, param: int) -> int { return -1 } +function jobs__day_one() -> int { return 1 } +function jobs__no_roll(b: int, s: int) -> void { } + +# (scope, money, rep): the game routes it to the wallet and the standings +export port JobsPay { + reward: fn(int, int, int) -> void +} diff --git a/packages/ludic.jobs/posts.ludic b/packages/ludic.jobs/posts.ludic new file mode 100644 index 00000000..f7b44541 --- /dev/null +++ b/packages/ludic.jobs/posts.ludic @@ -0,0 +1,51 @@ +# posts.ludic - what is on a board, slot by slot, and a board as the host says it +export function jobs_post_kind(b: int, s: int) -> int { return jobs__pk[jobs__at(b, s)] } +export function jobs_post_param(b: int, s: int) -> int { return jobs__pp[jobs__at(b, s)] } +export function jobs_post_need(b: int, s: int) -> int { return jobs__pn[jobs__at(b, s)] } +export function jobs_post_have(b: int, s: int) -> int { return jobs__ph[jobs__at(b, s)] } +export function jobs_post_money(b: int, s: int) -> int { return jobs__pm[jobs__at(b, s)] } +export function jobs_post_rep(b: int, s: int) -> int { return jobs__pr[jobs__at(b, s)] } +export function jobs_post_state(b: int, s: int) -> int { return jobs__ps[jobs__at(b, s)] } +export function jobs_post_day(b: int, s: int) -> int { return jobs__pd[jobs__at(b, s)] } + +export function jobs_slots(b: int) -> int { + if not jobs_board_ok(b) { return 0 } + return JobBoards[b].slots +} + +# the slots with something posted, in order +export function jobs_posted(b: int) -> []int { + let out = new []int + for s in 0 .. jobs_slots(b) { if jobs_post_kind(b, s) >= 0 { push(out, s) } } + return out +} + +# how many of a board's posts are paid +export function jobs_posts_done(b: int) -> int { + var n = 0 + for s in 0 .. jobs_slots(b) { if jobs_post_kind(b, s) >= 0 and jobs_post_state(b, s) == JOBS_DONE { n += 1 } } + return n +} + +export function jobs_total(b: int) -> int { + jobs__ensure() + return jobs__total[b] +} + +# a slot outright: the host's board on a guest, a test +export function jobs_post_set(b: int, s: int, kind: int, param: int, need: int, have: int, money: int, rep: int, state: int, day: int) -> void { + let k = jobs__at(b, s) + jobs__pk[k] = kind + jobs__pp[k] = param + jobs__pn[k] = need + jobs__ph[k] = have + jobs__pm[k] = money + jobs__pr[k] = rep + jobs__ps[k] = state + jobs__pd[k] = day +} + +export function jobs_set_total(b: int, n: int) -> void { + jobs__ensure() + jobs__total[b] = n +} diff --git a/packages/ludic.jobs/save.ludic b/packages/ludic.jobs/save.ludic new file mode 100644 index 00000000..aabe027b --- /dev/null +++ b/packages/ludic.jobs/save.ludic @@ -0,0 +1,71 @@ +# save.ludic - a scope's jobs and boards in a section of their own, by key: a job the game has since +# removed is dropped, a new one starts offered +function jobs__reset_scope(sc: int) -> void { + jobs__ensure() + for i in 0 .. JOB_COUNT { + if Jobs[i].scope != sc { continue } + jobs__st[i] = JOBS_OFFERED + jobs__hv[i] = 0 + } + for b in 0 .. BOARD_COUNT { + if JobBoards[b].scope != sc { continue } + for s in 0 .. JobBoards[b].slots { jobs__clear(jobs__off[b] + s) } + jobs__total[b] = 0 + jobs__serial[b] = 0 + } + jobs__primed = false +} + +function jobs__save_scope(sc: int) -> Val { + let v = Value.object() + let js = Value.object() + for i in 0 .. JOB_COUNT { + if Jobs[i].scope != sc or jobs__st[i] == JOBS_OFFERED { continue } + sv_put_ints(js, Jobs[i].key, [jobs__st[i], jobs__hv[i]]) + } + Value.put(v, "jobs", js) + let bs = Value.object() + for b in 0 .. BOARD_COUNT { + if JobBoards[b].scope != sc { continue } + let o = Value.object() + sv_put_int(o, "total", jobs__total[b]) + sv_put_int(o, "serial", jobs__serial[b]) + let slots = new []int + for s in 0 .. JobBoards[b].slots { + let k = jobs__off[b] + s + let row = [jobs__pk[k], jobs__pp[k], jobs__pn[k], jobs__ph[k], jobs__pb[k], jobs__pm[k], jobs__pr[k], jobs__ps[k], jobs__pd[k]] + for f in 0 .. len(row) { push(slots, row[f]) } + } + sv_put_ints(o, "slots", slots) + Value.put(bs, JobBoards[b].key, o) + } + Value.put(v, "boards", bs) + return v +} + +function jobs__load_scope(sc: int, v: Val) -> void { + jobs__reset_scope(sc) + if Value.has(v, "jobs") != 0 { + let js = Value.get(v, "jobs") + for i in 0 .. JOB_COUNT { + let p = sv_ints(js, Jobs[i].key) + if Jobs[i].scope == sc and len(p) == 2 { jobs_set(i, p[0], p[1]) } + } + } + if Value.has(v, "boards") == 0 { return } + let bs = Value.get(v, "boards") + for b in 0 .. BOARD_COUNT { + if JobBoards[b].scope != sc or Value.has(bs, JobBoards[b].key) == 0 { continue } + let o = Value.get(bs, JobBoards[b].key) + jobs__total[b] = sv_int(o, "total", 0) + jobs__serial[b] = sv_int(o, "serial", 0) + let r = sv_ints(o, "slots") + for s in 0 .. JobBoards[b].slots { + if s * 9 + 8 >= len(r) { break } + let k = jobs__off[b] + s + let q = s * 9 + jobs_post_set(b, s, r[q], r[q + 1], r[q + 2], r[q + 3], r[q + 5], r[q + 6], r[q + 7], r[q + 8]) + jobs__pb[k] = r[q + 4] + } + } +} diff --git a/packages/ludic.jobs/state.ludic b/packages/ludic.jobs/state.ludic new file mode 100644 index 00000000..b5eae680 --- /dev/null +++ b/packages/ludic.jobs/state.ludic @@ -0,0 +1,93 @@ +# state.ludic - each job's state and count, the posts on the boards, the done counts, the dice and +# the facts. Made on first use, so the registries are complete by then. +var jobs__st: []int = null +var jobs__hv: []int = null +var jobs__seen: []bool = null # an offer already said +var jobs__primed: bool = false # the first tick after a reset learns the offers quietly +var jobs__done_n: []int = null # handed in, per group + +# the posts, every board's slots end to end from jobs__off[b] +var jobs__off: []int = null +var jobs__pk: []int = null # kind, -1 empty +var jobs__pp: []int = null +var jobs__pn: []int = null +var jobs__ph: []int = null +var jobs__pb: []int = null # the evidence when it went up +var jobs__pm: []int = null +var jobs__pr: []int = null +var jobs__ps: []int = null +var jobs__pd: []int = null # the day it went up +var jobs__total: []int = null # handed in, per board +var jobs__serial: []int = null # posts made, per board: each roll its own seed + +var jobs__seed: int = 1000 +var jobs__dice: Rng = null +var jobs__q: Queue = null + +function jobs__ints(n: int, v: int) -> []int { + let out = new []int + for i in 0 .. n { push(out, v) } + return out +} + +function jobs__ensure() -> void { + if jobs__st != null { return } + jobs__st = jobs__ints(JOB_COUNT, JOBS_OFFERED) + jobs__hv = jobs__ints(JOB_COUNT, 0) + jobs__seen = new []bool + for i in 0 .. JOB_COUNT { push(jobs__seen, false) } + jobs__done_n = jobs__ints(JOBS_GROUPS, 0) + jobs__off = new []int + var n = 0 + for b in 0 .. BOARD_COUNT { + push(jobs__off, n) + n += JobBoards[b].slots + } + jobs__pk = jobs__ints(n, -1) + jobs__pp = jobs__ints(n, -1) + jobs__pn = jobs__ints(n, 0) + jobs__ph = jobs__ints(n, 0) + jobs__pb = jobs__ints(n, 0) + jobs__pm = jobs__ints(n, 0) + jobs__pr = jobs__ints(n, 0) + jobs__ps = jobs__ints(n, JOBS_OFFERED) + jobs__pd = jobs__ints(n, 0) + jobs__total = jobs__ints(BOARD_COUNT, 0) + jobs__serial = jobs__ints(BOARD_COUNT, 0) + jobs__dice = rng_new(jobs__seed) +} + +export function jobs_facts() -> Queue { + if jobs__q == null { jobs__q = queue_new("jobs.facts") } + return jobs__q +} + +function jobs__fact(what: int, job: int, b: int, s: int, have: int, need: int) -> void { + let f = new JobsFact + f.what = what + f.job = job + f.board = b + f.slot = s + f.have = have + f.need = need + if job >= 0 { + f.kind = Jobs[job].kind + f.param = Jobs[job].param + f.money = Jobs[job].money + f.rep = Jobs[job].rep + f.scope = Jobs[job].scope + } else { + f.kind = jobs__pk[jobs__off[b] + s] + f.param = jobs__pp[jobs__off[b] + s] + f.money = jobs__pm[jobs__off[b] + s] + f.rep = jobs__pr[jobs__off[b] + s] + f.scope = JobBoards[b].scope + } + q_push(jobs_facts(), f) +} + +# the base of every roll: a post's seed is this, the day, the board, the slot and how many before +export function jobs_config_seed(seed: int) -> void { + jobs__seed = seed + jobs__dice = rng_new(seed) +} diff --git a/packages/ludic.jobs/system.ludic b/packages/ludic.jobs/system.ludic new file mode 100644 index 00000000..51701e01 --- /dev/null +++ b/packages/ludic.jobs/system.ludic @@ -0,0 +1,54 @@ +# system.ludic - two systems, one per owner: "jobs" is this player's (personal jobs and boards, the +# done counts, the tick), "jobs.party" the party's, saved with the world +export function jobs_reset() -> void { + jobs__reset_scope(JOBS_SELF) + for g in 0 .. JOBS_GROUPS { jobs__done_n[g] = 0 } + q_clear(jobs_facts()) +} + +export function jobs_party_reset() -> void { jobs__reset_scope(JOBS_PARTY) } + +export function jobs_save() -> Val { + jobs__ensure() + let v = jobs__save_scope(JOBS_SELF) + sv_put_ints(v, "done", jobs__done_n) + return v +} + +export function jobs_load(v: Val, version: int) -> void { + jobs__load_scope(JOBS_SELF, v) + let d = sv_ints(v, "done") + for g in 0 .. JOBS_GROUPS { + jobs__done_n[g] = 0 + if g < len(d) { jobs__done_n[g] = d[g] } + } +} + +export function jobs_party_save() -> Val { + jobs__ensure() + return jobs__save_scope(JOBS_PARTY) +} + +export function jobs_party_load(v: Val, version: int) -> void { jobs__load_scope(JOBS_PARTY, v) } + +function jobs__tick(t: Tick) -> void { + jobs__offers() + jobs_recheck() +} + +export function jobs_system() -> System { + let s = system_new("jobs", PH_COMMIT) + s.reset = fn jobs_reset + s.tick = fn jobs__tick + s.save = fn jobs_save + s.load = fn jobs_load + return s +} + +export function jobs_party_system() -> System { + let s = system_new("jobs.party", PH_COMMIT) + s.reset = fn jobs_party_reset + s.save = fn jobs_party_save + s.load = fn jobs_party_load + return s +} diff --git a/packages/ludic.jobs/tests/jobs_test.ludic b/packages/ludic.jobs/tests/jobs_test.ludic new file mode 100644 index 00000000..41fc110d --- /dev/null +++ b/packages/ludic.jobs/tests/jobs_test.ludic @@ -0,0 +1,235 @@ +# jobs_test.ludic - written jobs gated by standing and story, counted by events and by state, readied +# and paid through a fake purse; a board rolled from the day; the party's scope; the save by key +import "ludic.jobs" +import "ludic.base" +program JobsTest { + numbers float + const K_FISH: int = 0 + const K_PHOTO: int = 1 + const K_WOOD: int = 2 + + def Jobs catch { group: 0, kind: K_FISH, need: 3, money: 30, rep: 5 } + def Jobs deer { group: 0, kind: K_PHOTO, param: 4, need: 1, money: 20, rep: 2, minrep: 10 } + def Jobs ten { group: 0, kind: K_FISH, need: 10, money: 70, rep: 12, scope: JOBS_PARTY } + def Jobs still { group: 1, need: 0, money: 60, rep: 12, minstage: 2 } + def JobBoards daily { slots: 3, keep: 1, auto: true, refill: false } + def JobBoards ada { slots: 2, keep: 7, scope: JOBS_PARTY } + + var rep: int = 0 + var stage: int = 0 + var day: int = 1 + var guest: bool = false + var fish: int = 0 # the state's count of fish + var paid: int = 0 + var paid_rep: int = 0 + var paid_scope: int = -1 + + function world_rep(sc: int) -> int { return rep } + function world_stage() -> int { return stage } + function world_day() -> int { return day } + function world_owns(sc: int) -> bool { return sc == JOBS_SELF or not guest } + function world_evidence(kind: int, param: int) -> int { + if kind == K_FISH { return fish } + return -1 + } + function world_roll(b: int, s: int) -> void { + let need = jobs_roll(2, 4) + if b == BOARD_DAILY { jobs_post(b, s, K_WOOD, -1, need, 7, 2) } else { jobs_post(b, s, K_FISH, -1, need, 9 * need, 3) } + } + function pay(sc: int, money: int, r: int) -> void { + paid += money + paid_rep += r + paid_scope = sc + } + + bind JobsWorld { rep: fn world_rep, stage: fn world_stage, day: fn world_day, owns: fn world_owns, evidence: fn world_evidence, roll: fn world_roll } + bind JobsPay { reward: fn pay } + + function fresh() -> void { + rep = 0 + stage = 0 + day = 1 + guest = false + fish = 0 + paid = 0 + paid_rep = 0 + jobs_reset() + jobs_party_reset() + } + + function count_of(what: int) -> int { + let fs = q_drain(jobs_facts()) + var n = 0 + for i in 0 .. len(fs) { if fs[i].what == what { n += 1 } } + return n + } + + test "the registry is the game's" { + expect_eq(JOB_COUNT, 4) + expect_eq(jobs_find("ten"), JOB_TEN) + expect_eq(BOARD_COUNT, 2) + } + + test "a gate of standing and story" { + fresh() + expect(jobs_offered(JOB_CATCH)) + expect(not jobs_offered(JOB_DEER)) + rep = 10 + expect(jobs_offered(JOB_DEER)) + expect(not jobs_offered(JOB_STILL)) + stage = 2 + expect(jobs_offered(JOB_STILL)) + expect_eq(len(jobs_at(0, -1)), 3) + } + + test "counted by events, readied, handed in and paid" { + fresh() + jobs_count(K_FISH, 0, 1) + expect_eq(jobs_have(JOB_CATCH), 0) + expect(jobs_take(JOB_CATCH)) + jobs_count(K_FISH, 0, 2) + expect_eq(jobs_state(JOB_CATCH), JOBS_ACTIVE) + jobs_count(K_FISH, 0, 5) + expect_eq(jobs_have(JOB_CATCH), 3) + expect(jobs_ready_now(JOB_CATCH)) + expect_eq(count_of(JOBS_F_COMPLETED), 1) + expect(jobs_hand_in(JOB_CATCH)) + expect(not jobs_hand_in(JOB_CATCH)) + expect_eq(paid, 30) + expect_eq(paid_rep, 5) + expect_eq(jobs_done(0), 1) + expect_eq(count_of(JOBS_F_HANDED_IN), 1) + } + + test "the state already proves the work" { + fresh() + fish = 2 + jobs_take(JOB_CATCH) + expect_eq(jobs_have(JOB_CATCH), 2) + fish = 3 + jobs_recheck() + expect(jobs_ready_now(JOB_CATCH)) + } + + test "a param narrows the count" { + fresh() + rep = 10 + jobs_take(JOB_DEER) + jobs_count(K_PHOTO, 3, 1) + expect_eq(jobs_have(JOB_DEER), 0) + jobs_count(K_PHOTO, 4, 1) + expect(jobs_ready_now(JOB_DEER)) + } + + test "the game's own play readies a job with no need" { + fresh() + jobs_take(JOB_STILL) + jobs_mark(JOB_STILL, 2) + expect_eq(jobs_have(JOB_STILL), 4) + expect_eq(jobs_state(JOB_STILL), JOBS_ACTIVE) + expect(jobs_complete(JOB_STILL)) + expect_eq(paid, 60) + expect_eq(jobs_done(1), 1) + } + + test "a guest counts its own and leaves the party's to the host" { + fresh() + guest = true + jobs_take(JOB_TEN) + jobs_take(JOB_CATCH) + jobs_count(K_FISH, 0, 2) + expect_eq(jobs_have(JOB_TEN), 0) + expect_eq(jobs_have(JOB_CATCH), 2) + jobs_count_in(JOBS_PARTY, K_FISH, 0, 4) + expect_eq(jobs_have(JOB_TEN), 4) + expect_eq(jobs_have(JOB_CATCH), 2) + } + + test "an offer is said once it opens, never for what was open at the reset" { + fresh() + jobs_system().tick(tick_new(0.1, 1, 0.0)) + expect_eq(count_of(JOBS_F_OFFERED), 0) + rep = 10 + jobs_system().tick(tick_new(0.1, 2, 0.0)) + expect_eq(count_of(JOBS_F_OFFERED), 1) + } + + test "a board rolled by the day, the same every time" { + fresh() + day = 3 + jobs_morning() + expect_eq(len(jobs_posted(BOARD_DAILY)), 3) + expect_eq(len(jobs_posted(BOARD_ADA)), 2) + let a = jobs_post_need(BOARD_DAILY, 0) * 100 + jobs_post_need(BOARD_DAILY, 1) * 10 + jobs_post_need(BOARD_DAILY, 2) + fresh() + day = 3 + jobs_morning() + let b = jobs_post_need(BOARD_DAILY, 0) * 100 + jobs_post_need(BOARD_DAILY, 1) * 10 + jobs_post_need(BOARD_DAILY, 2) + expect_eq(a, b) + expect(jobs_post_need(BOARD_DAILY, 0) >= 2 and jobs_post_need(BOARD_DAILY, 0) <= 4) + } + + test "a daily pays the moment it is met and waits for the morning" { + fresh() + day = 2 + jobs_morning() + let need = jobs_post_need(BOARD_DAILY, 1) + jobs_count(K_WOOD, -1, need) + expect_eq(jobs_post_state(BOARD_DAILY, 1), JOBS_DONE) + expect(jobs_posts_done(BOARD_DAILY) >= 1) + expect_eq(paid, 7 * jobs_posts_done(BOARD_DAILY)) + jobs_morning() + expect_eq(jobs_post_state(BOARD_DAILY, 1), JOBS_DONE) + day = 3 + jobs_morning() + expect_eq(jobs_post_state(BOARD_DAILY, 1), JOBS_ACTIVE) + } + + test "a post counts only what came after it went up, and is replaced when handed in" { + fresh() + fish = 5 + jobs_morning() + let need = jobs_post_need(BOARD_ADA, 0) + jobs_recheck() + expect_eq(jobs_post_have(BOARD_ADA, 0), 0) + fish = 5 + need + jobs_recheck() + expect_eq(jobs_post_state(BOARD_ADA, 0), JOBS_READY) + expect(jobs_post_hand_in(BOARD_ADA, 0)) + expect_eq(paid_scope, JOBS_PARTY) + expect_eq(jobs_total(BOARD_ADA), 1) + expect_eq(jobs_post_state(BOARD_ADA, 0), JOBS_ACTIVE) + } + + test "a stale post comes down after its days" { + fresh() + jobs_morning() + day = 7 + jobs_morning() + expect_eq(jobs_post_day(BOARD_ADA, 0), 1) + day = 8 + jobs_morning() + expect_eq(jobs_post_day(BOARD_ADA, 0), 8) + } + + test "each scope saves its own, by key" { + fresh() + jobs_take(JOB_CATCH) + jobs_count(K_FISH, 0, 2) + jobs_take(JOB_TEN) + jobs_count(K_FISH, 0, 1) + day = 2 + jobs_morning() + let body = jobs_save() + let world = jobs_party_save() + let need = jobs_post_need(BOARD_ADA, 1) + fresh() + jobs_load(body, 1) + expect_eq(jobs_have(JOB_CATCH), 3) + expect_eq(jobs_state(JOB_TEN), JOBS_OFFERED) + jobs_party_load(world, 1) + expect_eq(jobs_have(JOB_TEN), 1) + expect_eq(jobs_post_need(BOARD_ADA, 1), need) + expect_eq(jobs_post_day(BOARD_DAILY, 0), 2) + } +}