feat(packages): ludic.jobs - written jobs gated by standing and story, boards of posts rolled by the day on their own dice, progress counted from events and read back from state, handing in, rewards through a port, facts, a save section per scope

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-09-25 07:35:45 +03:00
parent e146c7a337
commit 411206e45a
14 changed files with 984 additions and 0 deletions

View file

@ -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<JobsFact>` | `{ 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
```

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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"

View file

@ -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 }
}

View file

@ -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
}

View file

@ -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

View file

@ -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
}

View file

@ -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
}

View file

@ -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]
}
}
}

View file

@ -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<JobsFact> = 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<JobsFact> {
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)
}

View file

@ -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
}

View file

@ -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)
}
}